/*
 * Copyright 2011 Clean GWT
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package com.cleangwt.page.client;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.cleangwt.user.client.DefaultPendingHandler;
import com.cleangwt.user.client.HashTokenizer;
import com.cleangwt.user.client.PendingHandler;
import com.google.gwt.core.shared.GWT;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.user.client.History;
import com.google.gwt.user.client.Window.Location;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.ui.IsWidget;
import com.google.gwt.user.client.ui.RootPanel;

/**
 * The page context for presenting pages with code splitting and prefetching.
 * 
 * <pre>
 * Example :
 * 
 * 1.To show a page, you just need to change the history token to the page name.
 * 
 * {@literal @}Page
 * public class TestView implements IsPage{
 * 	//...
 * }
 * 
 * You may call Hisotry.newItem("TestView") or PageContext.redirect("TestView") to obtain the page above.
 * 
 * 2.To make a page to be a split point, just put the @{@link CodeSplit} annotation at it.
 * 
 * 3.To manage pefetching, put @{@link Prefetch} annotation at pages with the same group name
 * 
 * {@literal @}Prefetch("group1")
 * {@literal @}CodeSplit
 * {@literal @}Page
 * public class Page1 implements IsPage{
 * 	//...
 * }
 * 
 * {@literal @}Prefetch("group1")
 * {@literal @}CodeSplit
 * {@literal @}Page
 * public class Page2 implements IsPage{
 * 	//...
 * }
 * 
 * When Page1, Page2 or any other pages in group1 is loaded, prefetcher will start to fetch code of pages in group1.
 * 
 * </pre>
 * 
 * @author Jess
 * @version 1.0
 * @since 1.0
 */
public abstract class PageContext {
  /**
   * Used for code splitting.
   */
  @Retention(value = RetentionPolicy.RUNTIME)
  @Target(value = {ElementType.TYPE})
  public @interface CodeSplit {
  }

  /**
   * Put this annotation at {@link IsPage} instance class, the {@link PageContext} will be able to
   * instantiate or present the page by name(default by the simple class name of page instance).
   */
  @Retention(value = RetentionPolicy.RUNTIME)
  @Target(value = {ElementType.TYPE})
  public @interface Page {
    /**
     * @return the page name
     */
    String value() default "";
  }

  /**
   * Implement this interface, and the method {@link #setPageParameters(String[])} will be invoked
   * when page parameter changed.
   */
  public interface PageParameterAware {
    /**
     * Sets the page parameters.
     * 
     * @param params the page parameters
     */
    void setPageParameters(String[] params);
  }

  /**
   * Depends on {@link CodeSplit}, used for code prefetching.
   */
  @Retention(value = RetentionPolicy.RUNTIME)
  @Target(value = {ElementType.TYPE})
  public @interface Prefetch {
    /**
     * @return the prefetching group
     */
    String value() default "";
  }

  private static PageContext Impl = GWT.create(PageContext.class);

  /**
   * Adds a page filter that should runs before presenting a page.
   * 
   * @param filter the filter to add
   */
  public static void addPageFilter(PageFilter filter) {
    if (Impl.pageFilters == null) {
      Impl.pageFilters = new ArrayList<PageFilter>();
    }
    Impl.pageFilters.add(filter);
  }

  /**
   * For current page is a {@link PageParameterAware}, just to changing page parameters.
   * 
   * @param params the parameters
   */
  public static void forward(String... params) {
    assert Impl.contentPage instanceof PageParameterAware : "#forward() is not supported for the current page";
    redirect(Impl.pageName, params);
  }

  /**
   * Gets the page name.
   * 
   * @return the page name, if there is no page, return null.
   */
  public static String getPageName() {
    return Impl.pageName;
  }

  /**
   * Gets the page container name.
   * 
   * @return the page container name, if there is no page container, return null.
   */
  public static String getContainerName() {
    return Impl.containerName;
  }

  /**
   * Redirects to a page by changing history token. the given token is composed from page name and
   * parameters(optional).
   * 
   * @param token the token is composed from page name and parameters.
   */
  public static void redirect(String token) {
    History.newItem(HashTokenizer.PAGE_TOKEN_PREFIX + token);
  }

  /**
   * Redirects to a page with the given parameters.
   * 
   * @param pageName the page name
   * @param params the parameters
   * 
   */
  public static void redirect(String pageName, String... params) {
    assert params != null : "Call PageContext#redirect(pageName) instead.";
    History.newItem(HashTokenizer.packPageToken(pageName, params));
  }

  /**
   * Refreshes the current page.
   */
  public static void refresh() {
    if (Impl.pageName != null) {
      // remove cache for creating a new page content.
      Impl.cachedPages.remove(Impl.pageName);
      Impl.present(Impl.pageName);
    } else {
      History.fireCurrentHistoryState();
    }
  }

  /**
   * Sets the default page, when no page or empty page token is specified, display it.
   * 
   * @param pageName the page name
   */
  public static void setDefaultPage(String pageName) {
    Impl.defaultPage = pageName;
  }

  /**
   * Sets the root page and updates the root panel.
   * 
   * @param widget the widget to set
   */
  public static void setRootPage(IsWidget widget) {
    setRootPage(null, widget);
  }

  /**
   * Sets root page with the given page name and page widget.
   * 
   * @param name the page name
   * @param widget the widget may be a {@link PageContainer}, {@link IsPage} or other widgets.
   */
  public static void setRootPage(String name, IsWidget widget) {
    IsWidget rootWidget = Impl.pageContainer == null ? Impl.contentPage : Impl.pageContainer;
    if (rootWidget != null) {
      RootPanel.get().remove(rootWidget);
    }

    // reset
    if (widget == null) {
      Impl.containerName = null;
      Impl.pageContainer = null;
      Impl.pageName = null;
      Impl.contentPage = null;
    } else if (widget instanceof PageContainer) {
      Impl.containerName = name;
      Impl.pageContainer = (PageContainer) widget;
      Impl.pageName = null;
      Impl.contentPage = null;
    } else {
      Impl.containerName = null;
      Impl.pageContainer = null;
      Impl.pageName = name;
      Impl.contentPage = widget;
    }

    if (widget != null) {
      RootPanel.get().add(widget);
    }
  }

  private Map<String, IsPage> cachedPages = new HashMap<String, IsPage>();
  private String defaultPage;
  private List<PageFilter> pageFilters;

  // loading state
  private boolean pageLoading;
  private PendingHandler pendingHandler;

  // current page
  private String containerName;
  private PageContainer pageContainer;
  private String pageName;
  private IsWidget contentPage;

  /**
   * Initialized by PageContextGenerator
   */
  protected Map<String, AsyncPage> asyncPages = new HashMap<String, AsyncPage>();

  private static boolean isBookmark(String token) {
    return !isRealEmptyToken(token) && !isPageToken(token);
  }

  private static boolean isPageToken(String token) {
    return token.startsWith(HashTokenizer.PAGE_TOKEN_PREFIX);
  }

  private static boolean isRealEmptyToken(String token) {
    return token.isEmpty() && !Location.getHref().endsWith("#");
  }

  /**
   * The default constructor for <code>PageContextGenerator</code>.
   */
  protected PageContext() {
    assert Impl == null : "PageContext has been initialized, do not try to create PageContext yourself.";
    Impl = this;

    // for history token changed
    History.addValueChangeHandler(new ValueChangeHandler<String>() {
      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        String token = event.getValue();
        boolean isBookmark = isBookmark(token);
        // if page has been loaded and token is a bookmark, do nothing
        if (contentPage != null && isBookmark) {
          return;
        }
        // if page has not been loaded(may just at module starting up or
        // refreshing) or the hash is empty or the page token
        String pageName = null;
        // is page token prfiex with page name
        if (isPageToken(token) && token.length() > 1) {
          pageName = HashTokenizer.parseIdentifier(token);
          // page parameters will be disposed later
        } else {
          // is page token without page name or the real empty token
          pageName = defaultPage;
        }
        // however, present the page
        present(pageName);
        // the hash is bookmark, scroll into view
        if (isBookmark) {
          Element e = Document.get().getElementById(token);
          if (e != null) {
            e.scrollIntoView();
          }
        }
      }
    });

  }

  /**
   * Sets the pending handler for loading page.
   * 
   * @param pendingHandler the pending handler for loading page
   */
  public static void setPendingHandler(PendingHandler pendingHandler) {
    Impl.pendingHandler = pendingHandler;
  }

  private void clearPage() {
    if (contentPage != null) {
      if (pageContainer != null) {
        pageContainer.clearPage();
      } else {
        RootPanel.get().remove(contentPage);
      }
    }
  }

  /**
   * Loads and present the specified page.
   * 
   * @param page name the page to load
   */
  private void loadAndPresent(final String pageName) {
    if (!pageLoading) {
      AsyncPage asyncPage = asyncPages.get(pageName);
      if (asyncPage == null) {
        throw new PageLoadingException("Page Not Found : " + pageName);
      }

      pageLoading = true;
      // reset
      clearPage();
      // ensure pending handler
      if (pendingHandler == null) {
        pendingHandler = DefaultPendingHandler.BLOCKED;
      }
      pendingHandler.onPending();
      asyncPage.runAsync(new AsyncCallback<IsPage>() {
        @Override
        public void onFailure(Throwable reason) {
          pageLoading = false;
          pendingHandler.onFinish();
          throw new PageLoadingException(reason.getMessage());
        }

        @Override
        public void onSuccess(IsPage result) {
          pageLoading = false;
          cachedPages.put(pageName, result);
          setPage(pageName, result);
          pendingHandler.onFinish();
        }
      });
    }
  }

  /**
   * Presents a page.
   * 
   * @param pageName the page name
   * @param page the page
   */
  private void setPage(String pageName, IsPage page) {
    if (pageContainer != null) {
      this.pageName = pageName;
      this.contentPage = page;
      pageContainer.setContentPage(pageName, page.asWidget());
    } else {
      setRootPage(pageName, page);
    }
    // set page parameters
    if (page instanceof PageParameterAware) {
      ((PageParameterAware) page).setPageParameters(HashTokenizer
          .parseHashParams(History.getToken()));
    }
  }

  /**
   * Sets and presents the page of specified name.
   * 
   * @param pageName the page name
   * @throws PageFilterException
   */
  private void present(String pageName) throws PageFilterException {
    // clear page
    if (pageName == null) {
      setPage(null, null);
      return;
    }
    // do page filter
    if (pageFilters != null) {
      for (PageFilter filter : pageFilters) {
        filter.doPageFilter(pageName);
      }
    }
    IsPage page = cachedPages.get(pageName);
    // page not been loaded
    if (page == null) {
      loadAndPresent(pageName);
    } else {
      setPage(pageName, page);
    }
  }

}
